package hu.uszeged.inf.wlab.stunner.screens.discovery.task;

import hu.uszeged.inf.wlab.stunner.screens.discovery.MainActivity;
import hu.uszeged.inf.wlab.stunner.utils.Constants;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;

import android.os.AsyncTask;
import android.os.Bundle;
import android.util.Log;
import de.javawi.jstun.attribute.ChangeRequest;
import de.javawi.jstun.attribute.ChangedAddress;
import de.javawi.jstun.attribute.ErrorCode;
import de.javawi.jstun.attribute.MappedAddress;
import de.javawi.jstun.attribute.MessageAttribute;
import de.javawi.jstun.attribute.MessageAttributeException;
import de.javawi.jstun.attribute.MessageAttributeParsingException;
import de.javawi.jstun.header.MessageHeader;
import de.javawi.jstun.header.MessageHeaderParsingException;
import de.javawi.jstun.test.DiscoveryInfo;
import de.javawi.jstun.util.UtilityException;

/**
 * Custom {@link AsyncTask} implementation to run the discovery test on a worker thread. Always use the
 * {@link StunDiscoveryTask#createTestArguments(String, int, String)} method to produce the execution arguments.
 * 
 * @author szelezsant
 */
public class StunDiscoveryTask extends AsyncTask<Bundle, String, DiscoveryInfo> {
	/** The log tag. */
	public static final String LOG_TAG = "JSTUN";
	/** Initial timeout value in milliseconds. */
	private static final int INIT_TIMEOUT_VALUE = 300;
	/** Constant log message shown when the test fails for some reason. */
	private static final String TEST_FAILED = "Failed to execute test!";
	/** Constant log message shown when the response header contains error. */
	private static final String MESSAGE_HEADER_ERROR = "Message header contains an Errorcode message attribute.";
	/** The size of the buffer used to receive messages. */
	private static final int BUFFER_SIZE = 200;
	/** Error code used when the received response is incomplete. */
	private static final int ERROR_INCOMPLETE_RESPONSE = 700;
	/** The maximal increment of timeout in milliseconds. */
	private static final int MAX_TIMEOUT_INCREMENT = 1600;
	/** The give-up limit of each test in milliseconds. */
	private static final int GIVEUP_LIMIT = 7900;

	/** The {@link InetAddress} object containing the STUN server address. */
	private InetAddress iaddress;
	/** The URL address of the STUN server. */
	private String stunServer;
	/** The port of the STUN server. */
	private int port;

	/** The {@link MappedAddress} object to track address changes. */
	private MappedAddress mappedAddress = null;
	/** The {@link ChangedAddress} object to track address changes. */
	private ChangedAddress changedAddress = null;
	/** Flag to indicate if the device is from behind of a NAT. */
	private boolean nodeNatted = true;
	/** The socket to establish communication with server. */
	private DatagramSocket socketTest1 = null;
	/** The info object to store the results. */
	private DiscoveryInfo discoInfo = null;
	/** The action to be executed in the onPostExecute block. */
	private final PostExecute postExecute;

	/**
	 * Constructor.
	 * 
	 * @param postExecute - the action to be executed onPostExecute.
	 */
	public StunDiscoveryTask(final PostExecute postExecute) {
		super();
		this.postExecute = postExecute;
	}

	@Override
	protected DiscoveryInfo doInBackground(final Bundle... params) {
		DiscoveryInfo discoveryInfo = null;
		try {
			final InetAddress testIP = InetAddress.getByName(params[0].getString(Constants.KEY_IP_ADDRESS));

			this.iaddress = testIP;
			this.stunServer = params[0].getString(Constants.KEY_SERVER_ADDRESS);
			this.port = params[0].getInt(Constants.KEY_SERVER_PORT);

			discoveryInfo = runTest();
		} catch (final SocketException socketException) {
			Log.e(getClass().getSimpleName(), TEST_FAILED, socketException);
		} catch (final UnknownHostException unknownHostException) {
			Log.e(getClass().getSimpleName(), TEST_FAILED, unknownHostException);
		} catch (final MessageAttributeParsingException messageAttributeParsingException) {
			Log.e(getClass().getSimpleName(), TEST_FAILED, messageAttributeParsingException);
		} catch (final MessageHeaderParsingException messageHeaderParsingException) {
			Log.e(getClass().getSimpleName(), TEST_FAILED, messageHeaderParsingException);
		} catch (final UtilityException utilityException) {
			Log.e(getClass().getSimpleName(), TEST_FAILED, utilityException);
		} catch (final IOException ioException) {
			Log.e(getClass().getSimpleName(), TEST_FAILED, ioException);
		} catch (final MessageAttributeException messageAttributeException) {
			Log.e(getClass().getSimpleName(), TEST_FAILED, messageAttributeException);
		}
		return discoveryInfo;
	}

	@Override
	protected void onProgressUpdate(final String... values) {
		super.onProgressUpdate(values);
		Log.d(LOG_TAG, values[0]);
		((MainActivity) postExecute).updateLog(values[0]);
	}

	@Override
	protected void onPostExecute(final DiscoveryInfo result) {
		if (null != postExecute) {
			postExecute.doPostExecute(result);
		}
		super.onPostExecute(result);
	}

	/**
	 * Use this method to produce the {@link Bundle} object to pass as the execution argument for the task.
	 * 
	 * @param serverAddress - the address of the selected STUN server.
	 * @param port - the port which the service is listening.
	 * @param ipAddress - the IPv4 address of the device.
	 * @return bundle object
	 */
	public static Bundle createTestArguments(final String serverAddress, final int port, final String ipAddress) {
		final Bundle bundle = new Bundle();
		bundle.putString(Constants.KEY_SERVER_ADDRESS, serverAddress);
		bundle.putString(Constants.KEY_IP_ADDRESS, ipAddress);
		bundle.putInt(Constants.KEY_SERVER_PORT, port);
		return bundle;
	}

	/**
	 * Starts the test.
	 * 
	 * @return {@link DiscoveryInfo} object containing the results.
	 * @throws UtilityException when the test fails for some reason.
	 * @throws IOException when there is problem in communication.
	 * @throws MessageAttributeException when there is an error in the response.
	 * @throws MessageHeaderParsingException when there is an error while parsing message header.
	 */
	private DiscoveryInfo runTest() throws UtilityException, IOException, MessageAttributeException, MessageHeaderParsingException {
		mappedAddress = null;
		changedAddress = null;
		nodeNatted = true;
		socketTest1 = null;
		discoInfo = new DiscoveryInfo(iaddress);

		if (firstTest() && secondTest() && firstTestRedo()) {
			thridTest();
		}

		socketTest1.close();

		return discoInfo;
	}

	/**
	 * Starts the first test for UDP availability.
	 * 
	 * @return true if UDP is enabled, false otherwise.
	 * @throws UtilityException when the test fails for some reason.
	 * @throws IOException when there is problem in communication.
	 * @throws MessageAttributeException when there is an error in the response.
	 * @throws MessageHeaderParsingException when there is an error while parsing message header.
	 */
	private boolean firstTest() throws UtilityException, IOException, MessageAttributeException, MessageHeaderParsingException {
		int timeSinceFirstTransmission = 0;
		int timeout = INIT_TIMEOUT_VALUE;
		while (true) {
			try {
				// Test 1 including response
				socketTest1 = new DatagramSocket(new InetSocketAddress(iaddress, 0));
				socketTest1.setReuseAddress(true);
				socketTest1.connect(InetAddress.getByName(stunServer), port);
				socketTest1.setSoTimeout(timeout);

				final MessageHeader sendMH = new MessageHeader(MessageHeader.MessageHeaderType.BindingRequest);
				sendMH.generateTransactionID();

				final ChangeRequest changeRequest = new ChangeRequest();
				sendMH.addMessageAttribute(changeRequest);

				final byte[] data = sendMH.getBytes();
				final DatagramPacket send = new DatagramPacket(data, data.length);
				socketTest1.send(send);
				publishProgress("Test 1: Binding Request sent.");

				MessageHeader receiveMH = new MessageHeader();
				while (!receiveMH.equalTransactionID(sendMH)) {
					final DatagramPacket receive = new DatagramPacket(new byte[BUFFER_SIZE], BUFFER_SIZE);
					socketTest1.receive(receive);
					receiveMH = MessageHeader.parseHeader(receive.getData());
					receiveMH.parseAttributes(receive.getData());
				}

				mappedAddress = (MappedAddress) receiveMH.getMessageAttribute(MessageAttribute.MessageAttributeType.MappedAddress);
				changedAddress = (ChangedAddress) receiveMH.getMessageAttribute(MessageAttribute.MessageAttributeType.ChangedAddress);
				final ErrorCode errorCode = (ErrorCode) receiveMH.getMessageAttribute(MessageAttribute.MessageAttributeType.ErrorCode);
				if (errorCode != null) {
					discoInfo.setError(errorCode.getResponseCode(), errorCode.getReason());
					publishProgress(MESSAGE_HEADER_ERROR);
					return false;
				}

				if (mappedAddress == null || changedAddress == null) {
					discoInfo
							.setError(
									ERROR_INCOMPLETE_RESPONSE,
									"The server is sending an incomplete response (Mapped Address and Changed Address message attributes are missing). The client should not retry.");
					publishProgress("Response does not contain a Mapped Address or Changed Address message attribute.");
					return false;
				} else {
					discoInfo.setPublicIP(mappedAddress.getAddress().getInetAddress());
					if (mappedAddress.getPort() == socketTest1.getLocalPort()
							&& mappedAddress.getAddress().getInetAddress().equals(socketTest1.getLocalAddress())) {
						publishProgress("Node is not natted.");
						nodeNatted = false;
					} else {
						publishProgress("Node is natted.");
					}
					publishProgress("Source address is: " + InetAddress.getByName(stunServer).getHostAddress());
					publishProgress("Mapped address is: " + mappedAddress.getAddress().toString());
					publishProgress("Changed address is: " + changedAddress.getAddress().toString());
					return true;
				}
			} catch (final SocketTimeoutException ste) {
				if (timeSinceFirstTransmission < GIVEUP_LIMIT) {
					publishProgress("Test 1: Socket timeout while receiving the response.");
					timeSinceFirstTransmission += timeout;
					int timeoutAddValue = timeSinceFirstTransmission * 2;
					if (timeoutAddValue > MAX_TIMEOUT_INCREMENT) {
						timeoutAddValue = MAX_TIMEOUT_INCREMENT;
					}
					timeout = timeoutAddValue;
				} else {
					// node is not capable of udp communication
					publishProgress("Test 1: Socket timeout while receiving the response. Maximum retry limit exceed. Give up.");
					discoInfo.setBlockedUDP();
					publishProgress("Node is not capable of UDP communication.");
					return false;
				}
			}
		}
	}

	/**
	 * Starts the second test to check NAT presence.
	 * 
	 * @return true if the node is natted, false otherwise.
	 * @throws UtilityException when the test fails for some reason.
	 * @throws IOException when there is problem in communication.
	 * @throws MessageAttributeException when there is an error in the response.
	 * @throws MessageHeaderParsingException when there is an error while parsing message header.
	 */
	private boolean secondTest() throws UtilityException, IOException, MessageAttributeException, MessageHeaderParsingException {
		int timeSinceFirstTransmission = 0;
		int timeout = INIT_TIMEOUT_VALUE;
		while (true) {
			try {
				// Test 2 including response
				final DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(iaddress, 0));
				sendSocket.connect(InetAddress.getByName(stunServer), port);
				sendSocket.setSoTimeout(timeout);

				final MessageHeader sendMH = new MessageHeader(MessageHeader.MessageHeaderType.BindingRequest);
				sendMH.generateTransactionID();

				final ChangeRequest changeRequest = new ChangeRequest();
				changeRequest.setChangeIP();
				changeRequest.setChangePort();
				sendMH.addMessageAttribute(changeRequest);

				final byte[] data = sendMH.getBytes();
				final DatagramPacket send = new DatagramPacket(data, data.length);
				sendSocket.send(send);
				publishProgress("Test 2: Binding Request sent.");

				final int localPort = sendSocket.getLocalPort();
				final InetAddress localAddress = sendSocket.getLocalAddress();

				sendSocket.close();

				final DatagramSocket receiveSocket = new DatagramSocket(localPort, localAddress);
				receiveSocket.connect(changedAddress.getAddress().getInetAddress(), changedAddress.getPort());
				receiveSocket.setSoTimeout(timeout);

				MessageHeader receiveMH = new MessageHeader();
				while (!receiveMH.equalTransactionID(sendMH)) {
					final DatagramPacket receive = new DatagramPacket(new byte[BUFFER_SIZE], BUFFER_SIZE);
					receiveSocket.receive(receive);
					receiveSocket.close();
					receiveMH = MessageHeader.parseHeader(receive.getData());
					receiveMH.parseAttributes(receive.getData());
				}
				final ErrorCode errorCode = (ErrorCode) receiveMH.getMessageAttribute(MessageAttribute.MessageAttributeType.ErrorCode);
				if (errorCode != null) {
					discoInfo.setError(errorCode.getResponseCode(), errorCode.getReason());
					publishProgress(MESSAGE_HEADER_ERROR);
					return false;
				}
				if (nodeNatted) {
					discoInfo.setFullCone();
					publishProgress("Node is behind a full-cone NAT.");
				} else {
					discoInfo.setOpenAccess();
					publishProgress("Node has open access to the Internet (or, at least the node is behind a full-cone NAT without translation).");
				}
				return false;
			} catch (final SocketTimeoutException ste) {
				if (timeSinceFirstTransmission < GIVEUP_LIMIT) {
					publishProgress("Test 2: Socket timeout while receiving the response.");
					timeSinceFirstTransmission += timeout;
					int timeoutAddValue = timeSinceFirstTransmission * 2;
					if (timeoutAddValue > MAX_TIMEOUT_INCREMENT) {
						timeoutAddValue = MAX_TIMEOUT_INCREMENT;
					}
					timeout = timeoutAddValue;
				} else {
					publishProgress("Test 2: Socket timeout while receiving the response. Maximum retry limit exceed. Give up.");
					if (nodeNatted) {
						// not is natted
						// redo test 1 with address and port as offered in the changed-address message attribute
						return true;
					} else {
						discoInfo.setSymmetricUDPFirewall();
						publishProgress("Node is behind a symmetric UDP firewall.");
						return false;
					}
				}
			}
		}
	}

	/**
	 * Starts the test to test NAT type.
	 * 
	 * @return true if the node is restricted, false otherwise.
	 * @throws UtilityException when the test fails for some reason.
	 * @throws IOException when there is problem in communication.
	 * @throws MessageAttributeException when there is an error in the response.
	 * @throws MessageHeaderParsingException when there is an error while parsing message header.
	 */
	private boolean firstTestRedo() throws UtilityException, IOException, MessageAttributeException, MessageHeaderParsingException {
		int timeSinceFirstTransmission = 0;
		int timeout = INIT_TIMEOUT_VALUE;
		while (true) {
			// redo test 1 with address and port as offered in the changed-address message attribute
			try {
				// Test 1 with changed port and address values
				socketTest1.connect(changedAddress.getAddress().getInetAddress(), changedAddress.getPort());
				socketTest1.setSoTimeout(timeout);

				final MessageHeader sendMH = new MessageHeader(MessageHeader.MessageHeaderType.BindingRequest);
				sendMH.generateTransactionID();

				final ChangeRequest changeRequest = new ChangeRequest();
				sendMH.addMessageAttribute(changeRequest);

				final byte[] data = sendMH.getBytes();
				final DatagramPacket send = new DatagramPacket(data, data.length);
				socketTest1.send(send);
				publishProgress("Test 1 redo with changed address: Binding Request sent.");

				MessageHeader receiveMH = new MessageHeader();
				while (!receiveMH.equalTransactionID(sendMH)) {
					final DatagramPacket receive = new DatagramPacket(new byte[BUFFER_SIZE], BUFFER_SIZE);
					socketTest1.receive(receive);
					receiveMH = MessageHeader.parseHeader(receive.getData());
					receiveMH.parseAttributes(receive.getData());
				}
				final MappedAddress ma2 = (MappedAddress) receiveMH
						.getMessageAttribute(MessageAttribute.MessageAttributeType.MappedAddress);
				final ErrorCode errorCode = (ErrorCode) receiveMH.getMessageAttribute(MessageAttribute.MessageAttributeType.ErrorCode);
				boolean returnValue = true;
				if (errorCode != null) {
					discoInfo.setError(errorCode.getResponseCode(), errorCode.getReason());
					publishProgress(MESSAGE_HEADER_ERROR);
					returnValue = false;
				}
				if (returnValue && ma2 == null) {
					discoInfo
							.setError(ERROR_INCOMPLETE_RESPONSE,
									"The server is sending an incomplete response (Mapped Address message attribute is missing). The client should not retry.");
					publishProgress("Response does not contain a Mapped Address message attribute.");
					returnValue = false;
				} else {
					if (returnValue && (mappedAddress.getPort() != ma2.getPort()
							|| !mappedAddress.getAddress().getInetAddress().equals(ma2.getAddress().getInetAddress()))) {
						discoInfo.setSymmetric();
						publishProgress("Node is behind a symmetric NAT.");
						returnValue = false;
					}
				}
				return returnValue;
			} catch (final SocketTimeoutException ste2) {
				if (timeSinceFirstTransmission < GIVEUP_LIMIT) {
					publishProgress("Test 1 redo with changed address: Socket timeout while receiving the response.");
					timeSinceFirstTransmission += timeout;
					int timeoutAddValue = timeSinceFirstTransmission * 2;
					if (timeoutAddValue > MAX_TIMEOUT_INCREMENT) {
						timeoutAddValue = MAX_TIMEOUT_INCREMENT;
					}
					timeout = timeoutAddValue;
				} else {
					publishProgress("Test 1 redo with changed address: Socket timeout while receiving the response.  Maximum retry limit exceed. Give up.");
					return false;
				}
			}
		}
	}

	/**
	 * Starts the test to determine NAT restrictions.
	 * 
	 * @throws UtilityException when the test fails for some reason.
	 * @throws IOException when there is problem in communication.
	 * @throws MessageAttributeException when there is an error in the response.
	 * @throws MessageHeaderParsingException when there is an error while parsing message header.
	 */
	private void thridTest() throws UtilityException, IOException, MessageAttributeException, MessageHeaderParsingException {
		int timeSinceFirstTransmission = 0;
		int timeout = INIT_TIMEOUT_VALUE;
		while (true) {
			try {
				// Test 3 including response
				final DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(iaddress, 0));
				sendSocket.connect(InetAddress.getByName(stunServer), port);
				sendSocket.setSoTimeout(timeout);

				final MessageHeader sendMH = new MessageHeader(MessageHeader.MessageHeaderType.BindingRequest);
				sendMH.generateTransactionID();

				final ChangeRequest changeRequest = new ChangeRequest();
				changeRequest.setChangePort();
				sendMH.addMessageAttribute(changeRequest);

				final byte[] data = sendMH.getBytes();
				final DatagramPacket send = new DatagramPacket(data, data.length);
				sendSocket.send(send);
				publishProgress("Test 3: Binding Request sent.");

				final int localPort = sendSocket.getLocalPort();
				final InetAddress localAddress = sendSocket.getLocalAddress();

				sendSocket.close();

				final DatagramSocket receiveSocket = new DatagramSocket(localPort, localAddress);
				receiveSocket.connect(InetAddress.getByName(stunServer), changedAddress.getPort());
				receiveSocket.setSoTimeout(timeout);

				MessageHeader receiveMH = new MessageHeader();
				while (!receiveMH.equalTransactionID(sendMH)) {
					final DatagramPacket receive = new DatagramPacket(new byte[BUFFER_SIZE], BUFFER_SIZE);
					receiveSocket.receive(receive);
					receiveSocket.close();
					receiveMH = MessageHeader.parseHeader(receive.getData());
					receiveMH.parseAttributes(receive.getData());
				}
				final ErrorCode errorCode = (ErrorCode) receiveMH.getMessageAttribute(MessageAttribute.MessageAttributeType.ErrorCode);
				if (errorCode != null) {
					discoInfo.setError(errorCode.getResponseCode(), errorCode.getReason());
					publishProgress(MESSAGE_HEADER_ERROR);
					return;
				}
				if (nodeNatted) {
					discoInfo.setRestrictedCone();
					publishProgress("Node is behind a restricted NAT.");
					return;
				}
			} catch (final SocketTimeoutException ste) {
				if (timeSinceFirstTransmission < GIVEUP_LIMIT) {
					publishProgress("Test 3: Socket timeout while receiving the response.");
					timeSinceFirstTransmission += timeout;
					int timeoutAddValue = timeSinceFirstTransmission * 2;
					if (timeoutAddValue > MAX_TIMEOUT_INCREMENT) {
						timeoutAddValue = MAX_TIMEOUT_INCREMENT;
					}
					timeout = timeoutAddValue;
				} else {
					publishProgress("Test 3: Socket timeout while receiving the response. Maximum retry limit exceed. Give up.");
					discoInfo.setPortRestrictedCone();
					publishProgress("Node is behind a port restricted NAT.");
					return;
				}
			}
		}
	}

	/**
	 * Interface to define the action to be executed when the task is finished.
	 * 
	 * @author szelezsant
	 */
	public interface PostExecute {
		/**
		 * Defines the actions to be executed on the main thread, after the task has finished.
		 * 
		 * @param info - the {@link DiscoveryInfo} object with the results.
		 */
		public void doPostExecute(final DiscoveryInfo info);
	}

}
